删除 k 位后,剩余数字的相对顺序不能改变,而且剩余长度固定为 num.length - k。因此问题等价于:
最直观的思路是反复找"左边比右边大"的位置并删掉左边那位;把它优化成一次遍历,就是单调栈。
从左到右扫描数字。只要当前数字比栈顶更小,并且还有删除次数,就删除栈顶:
把更小的数字尽量放到更高位,会让结果变小。如果扫描结束后仍有删除次数,说明栈已经整体非递减,此时从末尾删除最大的高位次最低的数字即可。
最后只做结果格式化:去掉前导零;如果结果为空,则返回 "0"。
给定一个用字符串表示的非负整数 num 和整数 k,恰好移除其中 k 位数字,使剩下的数字最小,并以字符串形式返回结果。
示例 1:
示例 2:
删除首位 "1" 后得到 "0200",返回时去掉前导零。
示例 3:
题目约束:
1 <= k <= num.length <= 10^5。num 只包含数字。"0" 本身,输入没有前导零。k 位,而不是最多删除 k 位。把 num 里的每个数字想象成一个人的"身价",你要保留 n - k 个人排成一队,让最终组成的数字最小。
从左往右看人:
digit),如果前面的人(栈顶)比他更贵(stack.top > digit),而你还剩踢人名额(remainingRemovals > 0),就把前面贵的踢掉。走完一轮,如果还有踢人名额,说明队伍已经是越来越贵(非递减)的了。那只能从队尾开始踢——踢最贵的、位置最靠后的。
最后去掉前导零即可。
如果你完全不想用栈,可以每轮都从头扫描,找到第一个左边比右边大的位置,删掉左边那个,重复 k 次:
这个思路非常好懂,而且结果正确。但每次删除都可能重新扫描和复制字符串,时间复杂度是 O(kn)。当 n = 10^5 时会超时,无法通过。
逐轮找下降点的核心操作是:遇到 左边 > 右边 时,删掉左边那位。
单调栈做的其实是同一件事,只是把所有轮次的扫描压缩成一次遍历:
pop),然后继续检查新的栈顶。这样不需要每轮重新扫描整个字符串,每个数字最多入栈一次、出栈一次,总时间降到 O(n)。
假设已经扫描到当前数字 digit,栈顶数字为 top。
top > digit如果还有删除次数,删除 top 比删除 digit 或更右侧的数字更优:
top 后,较小的 digit 可以提前到更高位。top,结果在这个更早的位置就是较大的数字。top。弹出一次后,新栈顶可能仍大于 digit,所以要使用 while 连续删除。
top <= digit此时不能为了当前数字删除栈顶:
top < digit,保留更小的高位显然更优。top === digit,删除前一个相等数字不能改善当前高位,反而会浪费删除机会。因此弹栈条件必须是严格大于,不能写成 >=。
维护:
stack:当前已经扫描前缀经过贪心删除后保留下来的数字。remainingRemovals:还必须删除的位数。当 remainingRemovals > 0 时,当前数字会不断消除栈尾的逆序对,所以栈尽量保持非递减。更准确地说:
stack 既是单调栈,也是最终答案的构造缓冲区。
如果扫描完整个字符串后 remainingRemovals > 0,说明在还有删除额度时,没有再遇到能触发 stack.top > digit 的下降位置。此时保留序列是非递减的。
例如:
扫描期间不会弹栈。对于非递减序列:
所以应依次删除末尾的 "5"、"4",得到 "123"。
以 num = "1432219"、k = 3 为例:
| 当前数字 | 操作 | 栈 | 剩余删除次数 |
|---|---|---|---|
1 | 入栈 | 1 | 3 |
4 | 1 < 4,入栈 | 14 | 3 |
3 | 删除 4,再入栈 | 13 | 2 |
2 | 删除 3,再入栈 | 12 | 1 |
2 | 栈顶等于当前数字,不删除 | 122 | 1 |
1 | 删除栈顶 2,再入栈 | 121 | 0 |
9 | 删除次数已用完,入栈 | 1219 | 0 |
最终不需要补删,也没有前导零,答案为 "1219"。
再看 num = "10200"、k = 1:
思路参考:JoshCrozier/leetcode-javascript。本文重新整理了变量命名,并补充贪心证明、末尾补删与前导零处理;原项目采用 MIT License。
如果不喜欢第二个 while 循环,可以用 slice 直接从末尾截掉剩余删除次数:
两种写法逻辑完全一致,只是末尾补删的方式不同。
需要注意的是,slice 不是去掉"不合法"的值,而是去掉"多余的长度"。主循环里每个数字都会入栈一次;如果扫描完整个字符串后 remaining 还有剩余,说明栈里保留下来的数字比目标长度 num.length - k 多了 remaining 个。此时序列已经是非递减的,末尾的数字影响最小,所以直接从末尾截掉剩余次数即可。
| 代码 | 作用 |
|---|---|
stack[stack.length - 1] > digit | 发现高位较大、低位较小的逆序关系 |
stack.pop() | 删除较大的高位数字 |
remainingRemovals-- | 每次弹栈都恰好使用一次删除机会 |
stack.push(digit) | 保留当前数字及其原始相对顺序 |
扫描后的第二个 while(或 slice) | 从非递减结果末尾完成剩余删除;slice 是截掉多余长度而非过滤非法值 |
replace(/^0+/, '') | 只格式化最终答案,不额外消耗删除次数 |
| `result |
可以从局部交换和剩余删除两部分证明。
当 top > digit 且还有删除次数时,考虑任何保留 top、却删除 digit 或更右侧数字的方案。把该方案改成删除 top 并保留 digit,删除数量不变,数字相对顺序仍合法。
两个结果在更靠左的位置首次产生差异:修改后的方案放入较小的 digit,所以一定更小。因此最优方案无需保留这个 top,弹栈不会漏掉最优答案。
连续应用这一交换,就能安全地删除所有位于当前 digit 左侧、且应该让位给它的较大栈顶。
如果扫描结束仍有删除次数,当前保留序列非递减。删除任意非末尾数字都会让其右侧一个不小于它的数字提前;删除末尾则保留最长的原有最小前缀。因此每一步删除末尾都不会比删除更靠前的位置差。
算法总共恰好执行 k 次删除,留下的又是所有合法子序列中字典序最小的一个。去除前导零只改变输出格式,不改变数值,所以最终答案正确。
k === num.length: 所有数字都被删除,返回 "0"。"12345" 不会在扫描时弹栈,必须从末尾补删。"54321" 中一个较小数字可能连续触发多次弹栈,所以必须使用 while。top > digit,不能写成 top >= digit。例如 "112"、k = 1 的最优结果是 "11"。"10200" 删除 "1" 后先得到 "0200",再格式化为 "200"。"1000"、k = 1 格式化后为空,应返回 "0"。k 位: 前导零的清理不是删除操作,不能用它代替剩余删除次数。'0'~'9' 的字典序与数值顺序一致,无需反复转换为 Number。shift(): 栈只在尾部执行 push() 和 pop(),才能保持摊还常数操作。设 n = num.length:
O(n)。join() 和清理前导零也都是 O(n)。O(n)。O(n),用于单调栈和最终字符串。虽然代码中存在嵌套的 while,但它不会让时间复杂度变成 O(n²),因为一次入栈的数字最多只会被弹出一次。
每次从左到右寻找第一个满足 num[i] > num[i + 1] 的位置并删除 num[i];如果不存在下降位置,就删除末尾。重复 k 次也能得到正确答案。
这个过程与单调栈的贪心选择相同,但每次删除都可能重新扫描和复制字符串,时间复杂度可达 O(kn),无法适应 10^5 的输入长度。
可以枚举所有长度为 n - k 的子序列并取最小值,但候选数量为组合数:
只适用于极小输入,可作为测试时的暴力对拍算法。
当前较小数字到来时,需要删除它左侧最近的较大保留数字,而且一次删除后还要继续检查新的栈顶。这种"从末尾反复撤销之前选择"的过程正适合栈。
它能让当前较小数字提前到更高位。两个等长结果的大小由第一个不同位置决定,所以高位变小带来的收益无法被后面的数字抵消。
>=?相等数字互换不会让高位变小,却会浪费删除次数。例如 "112"、k = 1,删除相等的第一个 "1" 会得到 "12",而保留它并最终删除 "2" 可以得到更小的 "11"。
删除原栈顶后,新栈顶仍可能大于当前数字。比如 "432" 中读到 "2" 且删除次数充足时,需要依次撤销之前保留的 "3",甚至继续检查 "4"。
剩余删除次数说明此前没有足够的下降位置,当前保留序列非递减。删除末尾不会改变更高位前缀,而删除前面会让相同或更大的数字前移,所以末尾最优。
算法已经通过弹栈和末尾补删恰好移除了 k 个原始位置。清除前导零只是把同一个数值转换成题目要求的规范字符串表示。
O(n)?外层让每个数字入栈一次,内层每次执行都会永久弹出一个已经入栈的数字。入栈和出栈总次数都不超过 n,所以总操作数是线性的。
栈中改为保存 [digit, index]。弹栈和末尾补删时记录对应下标,最后将这些下标排序或按题目要求输出;贪心逻辑不变。
if 弹栈,无法处理当前数字连续淘汰多个较大数字。>=,错误删除相等数字。"0"。O(n²)。先只回答第 1 题,再展开后续问题:
"1432219" 中读到 "3" 时应该删除 "4"?"12345"、k = 2 时,为什么扫描阶段一次都不会弹栈?> 改成 >= 后,"112"、k = 1 会得到什么错误结果?"1000"、k = 1 时,算法在哪一步把结果规范化为 "0"?